« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rwxr-xr-xsrc/upd8.js494
-rw-r--r--src/write/build-modes/index.js2
-rw-r--r--src/write/build-modes/live-dev-server.js31
-rw-r--r--src/write/build-modes/static-build.js423
-rw-r--r--src/write/page-template.js70
-rw-r--r--src/write/write-files.js6
6 files changed, 575 insertions, 451 deletions
diff --git a/src/upd8.js b/src/upd8.js
index d5a5c2a7..c3934a4c 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -40,7 +40,6 @@ import {listingSpec, listingTargetSpec} from './listing-spec.js';
 import urlSpec from './url-spec.js';
 
 import {processLanguageFile} from './data/language.js';
-import {serializeThings} from './data/serialize.js';
 
 import CacheableObject from './data/things/cacheable-object.js';
 
@@ -53,14 +52,17 @@ import {
   WIKI_INFO_FILE,
 } from './data/yaml.js';
 
-import * as pageSpecs from './page/index.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} from './util/sugar.js';
 import {replacerSpec} from './util/transform-content.js';
+import {generateURLs} from './util/urls.js';
+
+import {generateDevelopersCommentHTML} from './write/page-template.js';
+import * as buildModes from './write/build-modes/index.js';
 
 import {
   color,
@@ -73,34 +75,6 @@ import {
   progressPromiseAll,
 } from './util/cli.js';
 
-import {
-  queue,
-  showAggregate,
-  withEntries,
-} from './util/sugar.js';
-
-import {
-  generateURLs,
-  getPagePaths,
-  getURLsFrom,
-} from './util/urls.js';
-
-import {bindUtilities} from './write/bind-utilities.js';
-import {validateWrites} from './write/validate-writes.js';
-
-import {
-  generateDocumentHTML,
-  generateGlobalWikiDataJSON,
-  generateOEmbedJSON,
-  generateRedirectHTML,
-} from './write/page-template.js';
-
-import {
-  writePage,
-  writeSharedFilesAndPages,
-  writeSymlinks,
-} from './write/write-files.js';
-
 /*
 import {
   serializeContribs,
@@ -133,24 +107,32 @@ if (!validateReplacerSpec(replacerSpec, {find, link})) {
   process.exit();
 }
 
-// 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;
+async function main() {
+  Error.stackTraceLimit = Infinity;
+
+  const selectedBuildModeFlags = Object.keys(
+    await parseOptions(process.argv.slice(2), {
+      [parseOptions.handleUnknown]: () => {},
 
-  const entries = Object.entries(languagesToRun).filter(
-    ([key]) => key !== 'default'
-  );
+      ...Object.fromEntries(Object.keys(buildModes)
+        .map((key) => [key, {type: 'flag'}])),
+    }));
 
-  for (let i = 0; i < entries.length; i++) {
-    const [_key, language] = entries[i];
+  let selectedBuildModeFlag;
 
-    await fn(language, i, entries);
+  if (empty(selectedBuildModeFlags)) {
+    selectedBuildModeFlag = 'static-build';
+    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 {
+    selectedBuildModeFlag = selectedBuildModeFlags[0];
+    logInfo`Using specified build mode: ${selectedBuildModeFlag}`;
   }
-}
 
-async function main() {
-  Error.stackTraceLimit = Infinity;
+  const selectedBuildMode = buildModes[selectedBuildModeFlag];
 
   // This is about to get a whole lot more stuff put in it.
   const wikiData = {
@@ -158,7 +140,9 @@ async function main() {
     listingTargetSpec,
   };
 
-  const miscOptions = await parseOptions(process.argv.slice(2), {
+  const cliOptions = await parseOptions(process.argv.slice(2), {
+    ...selectedBuildMode.getCLIOptions(),
+
     // 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!
@@ -187,16 +171,6 @@ async function main() {
       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': {
-      type: 'value',
-    },
-
     // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e
     // kinda a pain to run every time, since it does necessit8te reading
     // every media file at run time. Pass this to skip it.
@@ -217,20 +191,6 @@ async function main() {
       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': {
-      type: 'flag',
-    },
-
     // Want sweet, sweet trace8ack info in aggreg8te error messages? This
     // will print all the juicy details (or at least the first relevant
     // line) right to your output, 8ut also pro8a8ly give you a headache
@@ -268,32 +228,26 @@ async function main() {
     'precache-data': {
       type: 'flag',
     },
-
-    [parseOptions.handleUnknown]: () => {},
   });
 
-  const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-  const mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA;
-  const langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
-  const outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT;
-
-  const skipThumbs = miscOptions['skip-thumbs'] ?? false;
-  const thumbsOnly = miscOptions['thumbs-only'] ?? false;
-  const noBuild = miscOptions['no-build'] ?? false;
-  const writeOneLanguage = miscOptions['lang'];
+  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 showAggregateTraces = miscOptions['show-traces'] ?? false;
+  const skipThumbs = cliOptions['skip-thumbs'] ?? false;
+  const thumbsOnly = cliOptions['thumbs-only'] ?? false;
+  const noBuild = cliOptions['no-build'] ?? false;
 
-  const appendIndexHTML = miscOptions['append-index-html'] ?? false;
+  const showAggregateTraces = cliOptions['show-traces'] ?? false;
 
-  const precacheData = miscOptions['precache-data'] ?? false;
-  const showInvalidPropertyAccesses = miscOptions['show-invalid-property-accesses'] ?? false;
+  const precacheData = cliOptions['precache-data'] ?? false;
+  const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? false;
 
   // Makes writing a little nicer on CPU theoretically, 8ut also costs in
   // performance right now 'cuz it'll w8 for file writes to 8e completed
   // 8efore moving on to more data processing. So, defaults to zero, which
   // disa8les the queue feature altogether.
-  const queueSize = +(miscOptions['queue-size'] ?? 0);
+  const queueSize = +(cliOptions['queue-size'] ?? 0);
 
   {
     let errored = false;
@@ -305,39 +259,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;
     }
   }
 
-  if (appendIndexHTML) {
-    logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
-    link.globalOptions.appendIndexHTML = true;
-  }
-
-  // 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,
@@ -502,8 +428,7 @@ async function main() {
   }
 
   const internalDefaultLanguage = await processLanguageFile(
-    path.join(__dirname, DEFAULT_STRINGS_FILE)
-  );
+    path.join(__dirname, DEFAULT_STRINGS_FILE));
 
   let languages;
   if (langPath) {
@@ -511,14 +436,11 @@ 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 = {};
   }
@@ -556,13 +478,6 @@ 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.`;
   }
 
   {
@@ -632,310 +547,55 @@ async function main() {
 
   if (noBuild) return;
 
-  const buildDictionary = pageSpecs;
-
-  await writeSymlinks({
-    srcRootDirname: __dirname,
-    mediaPath,
-    outputPath,
-    urls,
+  const developersComment = generateDevelopersCommentHTML({
+    buildTime: BUILD_TIME,
+    commit: COMMIT,
+    wikiData,
   });
 
-  await writeSharedFilesAndPages({
+  return selectedBuildMode.go({
+    cliOptions,
+    dataPath,
     mediaPath,
-    outputPath,
-    urls,
+    queueSize,
+    srcRootPath: __dirname,
 
-    language: finalDefaultLanguage,
+    defaultLanguage: finalDefaultLanguage,
+    languages,
     wikiData,
-    wikiDataJSON: generateGlobalWikiDataJSON({
-      serializeThings,
-      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;
-    }
-
-    writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => {
-      const writesFns = targets.map(target => () => {
-        const writes = pageSpec.write(target, {wikiData})?.slice() || [];
-        const valid = validateWrites(writes, {
-          functionName: flag + '.write',
-          urlSpec,
-        });
-        error ||=! valid;
-        return valid ? writes : [];
-      });
-
-      if (pageSpec.writeTargetless) {
-        writesFns.push(() => {
-          const writes = pageSpec.writeTargetless({wikiData});
-          const valid = validateWrites(writes, {
-            functionName: flag + '.writeTargetless',
-            urlSpec,
-          });
-          error ||=! valid;
-          return valid ? 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;
-  }
-
-  /*
-  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 = withEntries(languages, entries => entries
-          .filter(([key, language]) => key !== 'default' && !language.hidden)
-          .map(([_key, language]) => [
-            language.code,
-            getPagePaths({
-              outputPath,
-              urls,
-
-              baseDirectory:
-                (language === finalDefaultLanguage
-                  ? ''
-                  : language.code),
-              fullKey: 'localized.' + pageSubKey,
-              urlArgs,
-            }),
-          ]));
-
-        const paths = getPagePaths({
-          outputPath,
-          urls,
-
-          baseDirectory,
-          fullKey: 'localized.' + pageSubKey,
-          urlArgs,
-        });
-
-        const to = getURLsFrom({
-          urls,
-          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))
-          );
-        };
-
-        const bound = bindUtilities({
-          language,
-          to,
-          wikiData,
-        });
-
-        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');
-
-        const pageHTML = generateDocumentHTML(pageInfo, {
-          buildTime: BUILD_TIME,
-          cachebust: '?' + CACHEBUST,
-          commit: COMMIT,
-          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({
-          outputPath,
-          urls,
-
-          baseDirectory,
-          fullKey: 'localized.' + fromPath[0],
-          urlArgs: fromPath.slice(1),
-        });
-
-        const to = getURLsFrom({
-          urls,
-          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));
-  };
+    urls,
+    urlSpec,
 
-  await wrapLanguages(perLanguageFn, {
-    languages,
-    writeOneLanguage,
+    cachebust: '?' + CACHEBUST,
+    developersComment,
+    getSizeOfAdditionalFile,
   });
-
-  // The single most important step.
-  logInfo`Written!`;
 }
 
 // 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);
+  })();
 }
diff --git a/src/write/build-modes/index.js b/src/write/build-modes/index.js
new file mode 100644
index 00000000..91e39009
--- /dev/null
+++ b/src/write/build-modes/index.js
@@ -0,0 +1,2 @@
+export * as 'live-dev-server' from './live-dev-server.js';
+export * as 'static-build' from './static-build.js';
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
new file mode 100644
index 00000000..76b6d47f
--- /dev/null
+++ b/src/write/build-modes/live-dev-server.js
@@ -0,0 +1,31 @@
+import {logInfo} from '../../util/cli.js';
+
+export function getCLIOptions() {
+  // Stub.
+  return {};
+}
+
+export async function go({
+  _cliOptions,
+  _dataPath,
+  _mediaPath,
+  _queueSize,
+
+  _defaultLanguage,
+  _languages,
+  _srcRootPath,
+  _urls,
+  _urlSpec,
+  _wikiData,
+
+  _cachebust,
+  _developersComment,
+  _getSizeOfAdditionalFile,
+}) {
+  // Stub.
+  logInfo`So we are back in the mine!`;
+  logInfo`We are swinging our pickaxe in-`;
+  logInfo`...multiple directions,`;
+  logInfo`...multiple directions.`;
+  return true;
+}
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
new file mode 100644
index 00000000..eafb53d6
--- /dev/null
+++ b/src/write/build-modes/static-build.js
@@ -0,0 +1,423 @@
+import {bindUtilities} from '../bind-utilities.js';
+import {validateWrites} from '../validate-writes.js';
+
+import {
+  generateDocumentHTML,
+  generateGlobalWikiDataJSON,
+  generateOEmbedJSON,
+  generateRedirectHTML,
+} from '../page-template.js';
+
+import {
+  writePage,
+  writeSharedFilesAndPages,
+  writeSymlinks,
+} from '../write-files.js';
+
+import {serializeThings} from '../../data/serialize.js';
+
+import * as pageSpecs from '../../page/index.js';
+
+import link from '../../util/link.js';
+import {empty, queue, withEntries} from '../../util/sugar.js';
+import {getPagePaths, getURLsFrom} from '../../util/urls.js';
+
+import {
+  logError,
+  logInfo,
+  logWarn,
+  progressCallAll,
+  progressPromiseAll,
+} from '../../util/cli.js';
+
+const pageFlags = Object.keys(pageSpecs);
+
+export function getCLIOptions() {
+  return {
+    // 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': {
+      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': {
+      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',
+    },
+
+    // NOT for neatly ena8ling or disa8ling specific features of the site!
+    // This is only in charge of what general groups of files to write.
+    // 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.
+    ...Object.fromEntries(pageFlags.map((key) => [key, {type: 'flag'}])),
+  };
+}
+
+export async function go({
+  cliOptions,
+  _dataPath,
+  mediaPath,
+  queueSize,
+
+  defaultLanguage,
+  languages,
+  srcRootPath,
+  urls,
+  urlSpec,
+  wikiData,
+
+  cachebust,
+  developersComment,
+  getSizeOfAdditionalFile,
+}) {
+  const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT;
+  const appendIndexHTML = cliOptions['append-index-html'] ?? false;
+  const writeOneLanguage = cliOptions['lang'] ?? null;
+
+  if (!outputPath) {
+    logError`Expected ${'--out-path'} option or ${'HSMUSIC_OUT'} to be set`;
+    return false;
+  }
+
+  if (appendIndexHTML) {
+    logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
+    link.globalOptions.appendIndexHTML = true;
+  }
+
+  if (writeOneLanguage && !(writeOneLanguage in languages)) {
+    logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
+    return false;
+  } else if (writeOneLanguage) {
+    logInfo`Writing only language ${writeOneLanguage} this run.`;
+  } else {
+    logInfo`Writing all languages.`;
+  }
+
+  const selectedPageFlags = Object.keys(cliOptions)
+    .filter(key => pageFlags.includes(key));
+
+  const writeAll = empty(selectedPageFlags) || selectedPageFlags.includes('all');
+  logInfo`Writing site pages: ${writeAll ? 'all' : selectedPageFlags.join(', ')}`;
+
+  await writeSymlinks({
+    srcRootPath,
+    mediaPath,
+    outputPath,
+    urls,
+  });
+
+  await writeSharedFilesAndPages({
+    mediaPath,
+    outputPath,
+    urls,
+
+    language: defaultLanguage,
+    wikiData,
+    wikiDataJSON: generateGlobalWikiDataJSON({
+      serializeThings,
+      wikiData,
+    })
+  });
+
+  const buildSteps = writeAll
+    ? Object.entries(pageSpecs)
+    : Object.entries(pageSpecs)
+        .filter(([flag]) => selectedPageFlags.includes(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 false;
+    }
+
+    writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => {
+      const writesFns = targets.map(target => () => {
+        const writes = pageSpec.write(target, {wikiData})?.slice() || [];
+        const valid = validateWrites(writes, {
+          functionName: flag + '.write',
+          urlSpec,
+        });
+        error ||=! valid;
+        return valid ? writes : [];
+      });
+
+      if (pageSpec.writeTargetless) {
+        writesFns.push(() => {
+          const writes = pageSpec.writeTargetless({wikiData});
+          const valid = validateWrites(writes, {
+            functionName: flag + '.writeTargetless',
+            urlSpec,
+          });
+          error ||=! valid;
+          return valid ? writes : [];
+        });
+      }
+
+      return writesFns;
+    })).flat();
+
+    if (error) {
+      return false;
+    }
+  }
+
+  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 false;
+  }
+
+  /*
+  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 === defaultLanguage ? '' : 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 = withEntries(languages, entries => entries
+          .filter(([key, language]) => key !== 'default' && !language.hidden)
+          .map(([_key, language]) => [
+            language.code,
+            getPagePaths({
+              outputPath,
+              urls,
+
+              baseDirectory:
+                (language === defaultLanguage
+                  ? ''
+                  : language.code),
+              fullKey: 'localized.' + pageSubKey,
+              urlArgs,
+            }),
+          ]));
+
+        const paths = getPagePaths({
+          outputPath,
+          urls,
+
+          baseDirectory,
+          fullKey: 'localized.' + pageSubKey,
+          urlArgs,
+        });
+
+        const to = getURLsFrom({
+          urls,
+          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))
+          );
+        };
+
+        const bound = bindUtilities({
+          language,
+          to,
+          wikiData,
+        });
+
+        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');
+
+        const pageHTML = generateDocumentHTML(pageInfo, {
+          cachebust,
+          defaultLanguage,
+          developersComment,
+          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({
+          outputPath,
+          urls,
+
+          baseDirectory,
+          fullKey: 'localized.' + fromPath[0],
+          urlArgs: fromPath.slice(1),
+        });
+
+        const to = getURLsFrom({
+          urls,
+          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));
+  };
+
+  await wrapLanguages(perLanguageFn, {
+    languages,
+    writeOneLanguage,
+  });
+
+  // The single most important step.
+  logInfo`Written!`;
+  return true;
+}
+
+// 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);
+  }
+}
diff --git a/src/write/page-template.js b/src/write/page-template.js
index f7faeed0..efbc5795 100644
--- a/src/write/page-template.js
+++ b/src/write/page-template.js
@@ -10,11 +10,46 @@ import {
   img,
 } from '../misc-templates.js';
 
+export function generateDevelopersCommentHTML({
+  buildTime,
+  commit,
+  wikiData,
+}) {
+  const {name, canonicalBase} = wikiData.wikiInfo;
+  return `<!--\n` + [
+    canonicalBase
+      ? `hsmusic.wiki - ${name}, ${canonicalBase}`
+      : `hsmusic.wiki - ${name}`,
+    'Code copyright 2019-2023 Quasar Nebula et al (MIT License)',
+    ...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/',
+    ],
+    '***',
+    buildTime &&
+      `Site built: ${buildTime.toLocaleString('en-US', {
+        dateStyle: 'long',
+        timeStyle: 'long',
+      })}`,
+    commit &&
+      `Latest code commit: ${commit}`,
+  ]
+    .filter(Boolean)
+    .map(line => `    ` + line)
+    .join('\n') + `\n-->`;
+}
+
 export function generateDocumentHTML(pageInfo, {
-  buildTime = null,
-  cachebust = '',
-  commit = '',
+  cachebust,
   defaultLanguage,
+  developersComment,
   getThemeString,
   language,
   languages,
@@ -422,34 +457,7 @@ export function generateDocumentHTML(pageInfo, {
       'data-rebase-data': to('data.root'),
     },
     [
-      `<!--\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/',
-        ],
-        '***',
-        buildTime &&
-          `Site built: ${buildTime.toLocaleString('en-US', {
-            dateStyle: 'long',
-            timeStyle: 'long',
-          })}`,
-        commit &&
-          `Latest code commit: ${commit}`,
-      ]
-        .filter(Boolean)
-        .map(line => `    ` + line)
-        .join('\n') + `\n-->`,
+      developersComment,
 
       html.tag('head', [
         html.tag('title',
diff --git a/src/write/write-files.js b/src/write/write-files.js
index e448df3f..8b6ac3af 100644
--- a/src/write/write-files.js
+++ b/src/write/write-files.js
@@ -46,14 +46,14 @@ export async function writePage({
 }
 
 export function writeSymlinks({
-  srcRootDirname,
+  srcRootPath,
   mediaPath,
   outputPath,
   urls,
 }) {
   return progressPromiseAll('Writing site symlinks.', [
-    link(path.join(srcRootDirname, UTILITY_DIRECTORY), 'shared.utilityRoot'),
-    link(path.join(srcRootDirname, STATIC_DIRECTORY), 'shared.staticRoot'),
+    link(path.join(srcRootPath, UTILITY_DIRECTORY), 'shared.utilityRoot'),
+    link(path.join(srcRootPath, STATIC_DIRECTORY), 'shared.staticRoot'),
     link(mediaPath, 'media.root'),
   ]);