« 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.js631
1 files changed, 449 insertions, 182 deletions
diff --git a/src/upd8.js b/src/upd8.js
index f35f9e5..82ba8a6 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -34,7 +34,7 @@
 import '#import-heck';
 
 import {execSync} from 'node:child_process';
-import {readdir, readFile} from 'node:fs/promises';
+import {readdir, readFile, stat} from 'node:fs/promises';
 import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
@@ -47,8 +47,8 @@ import {bindFind, getAllFindSpecs} from '#find';
 import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
   from '#language';
 import {isMain, traverse} from '#node-utils';
+import {writeSearchData} from '#search';
 import {sortByName} from '#sort';
-import {empty, withEntries} from '#sugar';
 import {generateURLs, urlSpec} from '#urls';
 import {identifyAllWebRoutes} from '#web-routes';
 
@@ -61,6 +61,7 @@ import {
   logError,
   parseOptions,
   progressCallAll,
+  showHelpForOptions as unboundShowHelpForOptions,
 } from '#cli';
 
 import {
@@ -69,6 +70,13 @@ import {
   reportContentTextErrors,
 } from '#data-checks';
 
+import {
+  bindOpts,
+  empty,
+  indentWrap as unboundIndentWrap,
+  withEntries,
+} from '#sugar';
+
 import genThumbs, {
   CACHE_FILE as thumbsCacheFile,
   defaultMagickThreads,
@@ -93,8 +101,6 @@ import * as buildModes from './write/build-modes/index.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-const CACHEBUST = 23;
-
 let COMMIT;
 try {
   COMMIT = execSync('git log --format="%h %B" -n 1 HEAD', {cwd: __dirname}).toString().trim();
@@ -121,69 +127,109 @@ let showStepStatusSummary = false;
 async function main() {
   Error.stackTraceLimit = Infinity;
 
+  let paragraph = true;
+
   stepStatusSummary = {
     determineMediaCachePath:
-      {...defaultStepStatus, name: `determine media cache path`},
+      {...defaultStepStatus, name: `determine media cache path`,
+        for: ['thumbs', 'build']},
 
     migrateThumbnails:
-      {...defaultStepStatus, name: `migrate thumbnails`},
+      {...defaultStepStatus, name: `migrate thumbnails`,
+        for: ['thumbs']},
 
     loadThumbnailCache:
-      {...defaultStepStatus, name: `load thumbnail cache file`},
+      {...defaultStepStatus, name: `load thumbnail cache file`,
+        for: ['thumbs', 'build']},
 
     generateThumbnails:
-      {...defaultStepStatus, name: `generate thumbnails`},
+      {...defaultStepStatus, name: `generate thumbnails`,
+        for: ['thumbs']},
 
     loadDataFiles:
-      {...defaultStepStatus, name: `load and process data files`},
+      {...defaultStepStatus, name: `load and process data files`,
+        for: ['build']},
 
     linkWikiDataArrays:
-      {...defaultStepStatus, name: `link wiki data arrays`},
+      {...defaultStepStatus, name: `link wiki data arrays`,
+        for: ['build']},
 
     precacheCommonData:
-      {...defaultStepStatus, name: `precache common data`},
+      {...defaultStepStatus, name: `precache common data`,
+        for: ['build']},
 
     reportDirectoryErrors:
-      {...defaultStepStatus, name: `report directory errors`},
+      {...defaultStepStatus, name: `report directory errors`,
+        for: ['verify']},
 
     filterReferenceErrors:
-      {...defaultStepStatus, name: `filter reference errors`},
+      {...defaultStepStatus, name: `filter reference errors`,
+        for: ['verify']},
 
     reportContentTextErrors:
-      {...defaultStepStatus, name: `report content text errors`},
+      {...defaultStepStatus, name: `report content text errors`,
+        for: ['verify']},
 
     sortWikiDataArrays:
-      {...defaultStepStatus, name: `sort wiki data arrays`},
+      {...defaultStepStatus, name: `sort wiki data arrays`,
+        for: ['build']},
 
     precacheAllData:
-      {...defaultStepStatus, name: `precache nearly all data`},
+      {...defaultStepStatus, name: `precache nearly all data`,
+        for: ['build']},
 
     // TODO: This should be split into load/watch steps.
     loadInternalDefaultLanguage:
-      {...defaultStepStatus, name: `load internal default language`},
+      {...defaultStepStatus, name: `load internal default language`,
+        for: ['build']},
 
     loadLanguageFiles:
-      {...defaultStepStatus, name: `statically load custom language files`},
+      {...defaultStepStatus, name: `statically load custom language files`,
+        for: ['build']},
 
     watchLanguageFiles:
-      {...defaultStepStatus, name: `watch custom language files`},
+      {...defaultStepStatus, name: `watch custom language files`,
+        for: ['build']},
 
     initializeDefaultLanguage:
-      {...defaultStepStatus, name: `initialize default language`},
+      {...defaultStepStatus, name: `initialize default language`,
+        for: ['build']},
 
     verifyImagePaths:
-      {...defaultStepStatus, name: `verify missing/misplaced image paths`},
+      {...defaultStepStatus, name: `verify missing/misplaced image paths`,
+        for: ['verify']},
 
     preloadFileSizes:
-      {...defaultStepStatus, name: `preload file sizes`},
+      {...defaultStepStatus, name: `preload file sizes`,
+        for: ['build']},
+
+    buildSearchIndex:
+      {...defaultStepStatus, name: `generate search index`,
+        for: ['build', 'search']},
 
     identifyWebRoutes:
-      {...defaultStepStatus, name: `identify web routes`},
+      {...defaultStepStatus, name: `identify web routes`,
+        for: ['build']},
 
     performBuild:
-      {...defaultStepStatus, name: `perform selected build mode`},
+      {...defaultStepStatus, name: `perform selected build mode`,
+        for: ['build']},
   };
 
+  const stepsWhich = condition =>
+    Object.entries(stepStatusSummary)
+      .filter(([_key, value]) => condition(value))
+      .map(([key]) => key);
+
+  /* eslint-disable-next-line no-unused-vars */
+  const stepsFor = (...which) =>
+    stepsWhich(step =>
+      which.some(w => step.for?.includes(w)));
+
+  const stepsNotFor = (...which) =>
+    stepsWhich(step =>
+      which.every(w => !step.for?.includes(w)));
+
   const defaultQueueSize = 500;
 
   const buildModeFlagOptions = (
@@ -200,21 +246,24 @@ async function main() {
     }));
 
   let selectedBuildModeFlag;
-  let usingDefaultBuildMode;
 
   if (empty(selectedBuildModeFlags)) {
-    selectedBuildModeFlag = 'static-build';
-    usingDefaultBuildMode = true;
+    // No build mode selected. This is not a valid state for building the wiki,
+    // but we want to let access to --help, so we'll show a message about what
+    // to do later.
+    selectedBuildModeFlag = null;
   } else if (selectedBuildModeFlags.length > 1) {
     logError`Building multiple modes (${selectedBuildModeFlags.join(', ')}) at once not supported.`;
-    logError`Please specify a maximum of one build mode.`;
+    logError`Please specify one build mode.`;
     return false;
   } else {
     selectedBuildModeFlag = selectedBuildModeFlags[0];
-    usingDefaultBuildMode = false;
   }
 
-  const selectedBuildMode = buildModes[selectedBuildModeFlag];
+  const selectedBuildMode =
+    (selectedBuildModeFlag
+      ? buildModes[selectedBuildModeFlag]
+      : null);
 
   // This is about to get a whole lot more stuff put in it.
   const wikiData = {
@@ -222,7 +271,10 @@ async function main() {
     listingTargetSpec,
   };
 
-  const buildOptions = selectedBuildMode.getCLIOptions();
+  const buildOptions =
+    (selectedBuildMode
+      ? selectedBuildMode.getCLIOptions()
+      : {});
 
   const commonOptions = {
     'help': {
@@ -234,7 +286,7 @@ async function main() {
     // and like a jillion other things too. Pretty much everything which
     // makes an individual wiki what it is goes here!
     'data-path': {
-      help: `Specify path to data directory, including YAML files that cover all info about wiki content, layout, and structure\n\nAlways required for wiki building, but may be provided via the HSMUSIC_DATA environment variable instead`,
+      help: `Specify path to data directory, including YAML files that cover all info about wiki content, layout, and structure\n\nAlways required for wiki building; may be provided via the HSMUSIC_DATA environment variable`,
       type: 'value',
     },
 
@@ -242,17 +294,17 @@ async function main() {
     // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants
     // near the top of this file (upd8.js).
     'media-path': {
-      help: `Specify path to media directory, including album artwork and additional files, as well as custom site layout media and other media files for reference or linking in wiki content\n\nAlways required for wiki building, but may be provided via the HSMUSIC_MEDIA environment variable instead`,
+      help: `Specify path to media directory, including album artwork and additional files, as well as custom site layout media and other media files for reference or linking in wiki content\n\nAlways required for wiki building; may be provided via the HSMUSIC_MEDIA environment variable`,
       type: 'value',
     },
 
     'media-cache-path': {
-      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred either by loading "media-cache" from --cache-path, or by adding "-cache" to the end of the media directory\n\nAlso may be provided via the HSMUSIC_MEDIA_CACHE environment variable`,
+      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred either by loading "media-cache" from --cache-path, or by adding "-cache" to the end of the media directory\n\nMay be provided via the HSMUSIC_MEDIA_CACHE environment variable`,
       type: 'value',
     },
 
     'cache-path': {
-      help: `Specify path to general cache directory, usually containing generated thumbnails and assorted files reused between builds\n\nRequired for some features and may always be required if you're starting a new workspace\n\nAlso may be provided via the HSMUSIC_CACHE environment varaible`,
+      help: `Specify path to general cache directory, usually containing generated thumbnails and assorted files reused between builds\n\nAlways required for wiki building; may be provided via the HSMUSIC_CACHE environment varaible`,
       type: 'value',
     },
 
@@ -311,6 +363,16 @@ async function main() {
       type: 'flag',
     },
 
+    'refresh-search': {
+      help: `Generate the text search index this build, instead of waiting for the automatic delay`,
+      type: 'flag',
+    },
+
+    'skip-search': {
+      help: `Skip creation of the text search index no matter what, even if it'd normally be scheduled for now`,
+      type: 'flag',
+    },
+
     // Just working on data entries and not interested in actually
     // generating site HTML yet? This flag will cut execution off right
     // 8efore any site 8uilding actually happens.
@@ -390,6 +452,18 @@ async function main() {
     },
   };
 
+  const indentWrap =
+    bindOpts(unboundIndentWrap, {
+      wrap,
+    });
+
+  const showHelpForOptions =
+    bindOpts(unboundShowHelpForOptions, {
+      [bindOpts.bindIndex]: 0,
+      indentWrap,
+      sort: sortByName,
+    });
+
   const cliOptions = await parseOptions(process.argv.slice(2), {
     // We don't want to error when we receive these options, so specify them
     // here, even though we won't be doing anything with them later.
@@ -400,89 +474,71 @@ async function main() {
     ...buildOptions,
   });
 
-  if (cliOptions['help']) {
-    const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)});
-
-    const showOptions = (msg, options) => {
-      console.log(colors.bright(msg));
-
-      const entries = Object.entries(options);
-      const sortedOptions = sortByName(entries
-        .map(([name, descriptor]) => ({name, descriptor})));
-
-      if (!sortedOptions.length) {
-        console.log(`(No options available)`)
-      }
-
-      let justInsertedPaddingLine = false;
-
-      for (const {name, descriptor} of sortedOptions) {
-        if (descriptor.alias) {
-          continue;
-        }
-
-        const aliases = entries
-          .filter(([_name, {alias}]) => alias === name)
-          .map(([name]) => name);
-
-        let wrappedHelp, wrappedHelpLines = 0;
-        if (descriptor.help) {
-          wrappedHelp = indentWrap(4, descriptor.help);
-          wrappedHelpLines = wrappedHelp.split('\n').length;
-        }
-
-        if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
-          console.log('');
-        }
-
-        console.log(colors.bright(` --` + name) +
-          (aliases.length
-            ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})`
-            : '') +
-          (descriptor.help
-            ? ''
-            : colors.dim('  (no help provided)')));
-
-        if (wrappedHelp) {
-          console.log(wrappedHelp);
-        }
-
-        if (wrappedHelpLines > 1) {
-          console.log('');
-          justInsertedPaddingLine = true;
-        } else {
-          justInsertedPaddingLine = false;
-        }
-      }
-
-      if (!justInsertedPaddingLine) {
-        console.log(``);
-      }
-    };
+  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
 
+  if (cliOptions['help']) {
     console.log(
-      colors.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) +
+      colors.bright(`hsmusic (aka. Homestuck Music Wiki, HSMusic Wiki)\n`) +
       `static wiki software cataloguing collaborative creation\n`);
 
-    console.log(indentWrap(0,
-      `The \`hsmusic\` command provides basic control over all parts of generating user-visible HTML pages and website content/structure from provided data, media, and language directories.\n` +
+    console.log(indentWrap(
+      `The \`hsmusic\` command provides basic control over ` +
+      `all parts of generating user-visible HTML pages ` +
+      `and website content/structure ` +
+      `from provided data, media, and language directories.\n` +
       `\n` +
       `CLI options are divided into three groups:\n`));
-    console.log(` 1) ` + indentWrap(4,
-      `Common options: These are shared by all build modes and always have the same essential behavior`).trim());
-    console.log(` 2) ` + indentWrap(4,
-      `Build mode selection: One build mode may be selected (or else the default, --static-build, is used), and it decides which entire set of behavior to use for providing site content to the user`).trim());
-    console.log(` 3) ` + indentWrap(4,
-      `Build options: Each build mode has a set of unique options which customize behavior for that build mode`).trim());
+
+    console.log(` 1) ` + indentWrap(
+      `Common options: ` +
+      `These are shared by all build modes ` +
+      `and always have the same essential behavior`,
+      {spaces: 4, bullet: true}));
+
+    console.log(` 2) ` + indentWrap(
+      `Build mode selection: ` +
+      `One build mode should be selected, ` +
+      `and it decides the main set of behavior to use ` +
+      `for presenting or interacting with site content`,
+      {spaces: 4, bullet: true}));
+
+    console.log(` 3) ` + indentWrap(
+      `Build options: ` +
+      `Each build mode has a set of unique options ` +
+      `which customize behavior for that build mode`,
+      {spaces: 4, bullet: true}));
+
     console.log(``);
 
-    showOptions(`Common options`, commonOptions);
-    showOptions(`Build mode selection`, buildModeFlagOptions);
+    showHelpForOptions({
+      heading: `Common options`,
+      options: commonOptions,
+      wrap,
+    });
+
+    showHelpForOptions({
+      heading: `Build mode selection`,
+      options: buildModeFlagOptions,
+      wrap,
+    });
 
-    if (buildOptions) {
-      showOptions(`Build options for --${selectedBuildModeFlag} (${
-        usingDefaultBuildMode ? 'default' : 'selected'
-      })`, buildOptions);
+    if (selectedBuildMode) {
+      showHelpForOptions({
+        heading: `Build options for --${selectedBuildModeFlag}`,
+        options: buildOptions,
+        wrap,
+      });
+    } else {
+      console.log(
+        `Specify a build mode and run with ${colors.bright('--help')} again for info\n` +
+        `about the options for that build mode.`);
+    }
+
+    for (const step of Object.values(stepStatusSummary)) {
+      Object.assign(step, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--help provided`,
+      });
     }
 
     return true;
@@ -496,8 +552,6 @@ async function main() {
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
   const noInput = cliOptions['no-input'] ?? false;
 
-  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
-
   const showAggregateTraces = cliOptions['show-traces'] ?? false;
 
   const precacheMode = cliOptions['precache-mode'] ?? 'common';
@@ -519,10 +573,10 @@ async function main() {
   }
 
   if (!wikiCachePath) {
-    logWarn`No --cache-path option nor HSMUSIC_CACHE set; provide for more features`;
+    logError`${`Expected --cache-path option or HSMUSIC_CACHE to be set`}`;
   }
 
-  if (!dataPath || !mediaPath) {
+  if (!dataPath || !mediaPath || !wikiCachePath) {
     return false;
   }
 
@@ -533,65 +587,86 @@ async function main() {
       status: STATUS_NOT_APPLICABLE,
       annotation: `--no-build provided`,
     });
-  } else {
-    if (usingDefaultBuildMode) {
-      logInfo`No build mode specified, will use default: ${selectedBuildModeFlag}`;
-    } else {
-      logInfo`Will use specified build mode: ${selectedBuildModeFlag}`;
-    }
   }
 
   // Finish setting up defaults by combining information from all options.
 
   const _fallbackStep = (stepKey, {
     default: defaultValue,
+    cli: cliArg,
+    buildConfig: buildConfigKey = null,
+  }) => {
+    const buildConfig = selectedBuildMode?.config?.[buildConfigKey];
+    const {[stepKey]: step} = stepStatusSummary;
+
+    const cliEntries =
+      (cliArg === null || cliArg === undefined
+        ? []
+     : Array.isArray(cliArg)
+        ? cliArg
+        : [cliArg]);
 
-    cli: {
+    for (const {
       flag: cliFlag = null,
       negate: cliFlagNegates = false,
       warn: cliFlagWarning = null,
-    } = {},
-
-    buildConfig: buildConfigKey,
-  }) => {
-    const {[buildConfigKey]: buildConfig} = selectedBuildMode.config;
-    const {[stepKey]: step} = stepStatusSummary;
+      disable: cliFlagDisablesSteps = [],
+    } of cliEntries) {
+      if (!cliOptions[cliFlag]) {
+        continue;
+      }
 
-    if (cliFlag && cliOptions[cliFlag]) {
       const cliPart = `--` + cliFlag;
       const modePart = `--` + selectedBuildModeFlag;
+
       if (buildConfig?.applicable === false) {
         if (cliFlagNegates) {
           logWarn`${cliPart} provided, but ${modePart} already skips this step`;
           logWarn`Redundant option ${cliPart}`;
+          continue;
         } else {
           logWarn`${cliPart} provided, but this step isn't applicable for ${modePart}`;
           logWarn`Ignoring option ${cliPart}`;
+          continue;
         }
-      } else if (buildConfig?.required === true) {
+      }
+
+      if (buildConfig?.required === true) {
         if (cliFlagNegates) {
           logWarn`${cliPart} provided, but ${modePart} requires this step`;
           logWarn`Ignoring option ${cliPart}`;
+          continue;
         } else {
           logWarn`${cliPart} provided, but ${modePart} already requires this step`;
           logWarn`Redundant option ${cliPart}`;
+          continue;
         }
-      } else {
-        step.status =
-          (cliFlagNegates
-            ? STATUS_NOT_APPLICABLE
-            : STATUS_NOT_STARTED);
+      }
 
-        step.annotation = `--${cliFlag} provided`;
+      step.status =
+        (cliFlagNegates
+          ? STATUS_NOT_APPLICABLE
+          : STATUS_NOT_STARTED);
 
-        if (cliFlagWarning) {
-          for (const line of cliFlagWarning.split('\n')) {
-            logWarn(line);
-          }
+      step.annotation = `--${cliFlag} provided`;
+
+      if (cliFlagWarning) {
+        for (const line of cliFlagWarning.split('\n')) {
+          logWarn(line);
         }
+      }
 
-        return;
+      for (const step of cliFlagDisablesSteps) {
+        const summary = stepStatusSummary[step];
+        if (summary.status === STATUS_NOT_APPLICABLE && summary.annotation) {
+          stepStatusSummary.performBuild.annotation += `; --${cliFlag} provided`;
+        } else {
+          summary.status = STATUS_NOT_APPLICABLE;
+          summary.annotation = `--${cliFlag} provided`;
+        }
       }
+
+      return;
     }
 
     if (buildConfig?.required === true) {
@@ -619,12 +694,22 @@ async function main() {
     }
 
     switch (defaultValue) {
-      case 'skip':
+      case 'skip': {
         step.status = STATUS_NOT_APPLICABLE;
-        if (cliFlag && !cliFlagNegates) {
-          step.annotation = `--${cliFlag} not provided`;
+
+        const enablingFlags =
+          cliEntries
+            .filter(({negate}) => !negate)
+            .map(({flag}) => flag);
+
+        if (!empty(enablingFlags)) {
+          step.annotation =
+            enablingFlags.map(flag => `--${flag}`).join(', ') +
+            ` not provided`;
         }
+
         break;
+      }
 
       case 'perform':
         break;
@@ -649,7 +734,6 @@ async function main() {
 
     fallbackStep('filterReferenceErrors', {
       default: 'perform',
-      buildConfig: null,
       cli: {
         flag: 'skip-reference-validation',
         negate: true,
@@ -662,17 +746,20 @@ async function main() {
     fallbackStep('generateThumbnails', {
       default: 'perform',
       buildConfig: 'thumbs',
-      cli: {
-        flag: 'skip-thumbs',
-        negate: true,
-      },
+      cli: [
+        {flag: 'thumbs-only', disable: stepsNotFor('thumbs')},
+        {flag: 'skip-thumbs', negate: true},
+      ],
     });
 
     fallbackStep('migrateThumbnails', {
       default: 'skip',
-      buildConfig: null,
       cli: {
         flag: 'migrate-thumbs',
+        disable: [
+          ...stepsNotFor('thumbs'),
+          'generateThumbnails',
+        ],
       },
     });
 
@@ -686,10 +773,98 @@ async function main() {
     });
 
     fallbackStep('identifyWebRoutes', {
-      default: 'skip',
+      default: 'perform',
       buildConfig: 'webRoutes',
     });
 
+    decideBuildSearchIndex: {
+      fallbackStep('buildSearchIndex', {
+        default: 'skip',
+        buildConfig: 'search',
+        cli: [
+          {flag: 'refresh-search'},
+          {flag: 'skip-search', negate: true},
+        ],
+      });
+
+      if (cliOptions['refresh-search'] || cliOptions['skip-search']) {
+        if (cliOptions['refresh-search']) {
+          logInfo`${'--refresh-search'} provided, will generate search fresh this build.`;
+        }
+
+        break decideBuildSearchIndex;
+      }
+
+      if (stepStatusSummary.buildSearchIndex.status !== STATUS_NOT_APPLICABLE) {
+        break decideBuildSearchIndex;
+      }
+
+      if (selectedBuildMode?.config?.search?.default === 'skip') {
+        break decideBuildSearchIndex;
+      }
+
+      // TODO: OK this is a little silly.
+      if (stepStatusSummary.buildSearchIndex.annotation?.startsWith('N/A')) {
+        break decideBuildSearchIndex;
+      }
+
+      const indexFile = path.join(wikiCachePath, 'search', 'index.json')
+      let stats;
+      try {
+        stats = await stat(indexFile);
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          Object.assign(stepStatusSummary.buildSearchIndex, {
+            status: STATUS_NOT_STARTED,
+            annotation: `search/index.json not present, will create`,
+          });
+
+          logInfo`Looks like the search cache doesn't exist.`;
+          logInfo`It'll be generated fresh, this build!`;
+        } else {
+          Object.assign(stepStatusSummary.buildSearchIndex, {
+            status: STATUS_NOT_APPLICABLE,
+            annotation: `error getting search index stats`,
+          });
+
+          if (!paragraph) console.log('');
+          console.error(error);
+
+          logWarn`There was an error checking the search index file, located at:`;
+          logWarn`${indexFile}`;
+          logWarn`You may want to toss out the "search" folder; it'll be generated`;
+          logWarn`anew, if you do, and may fix this error.`;
+        }
+
+        paragraph = false;
+        break decideBuildSearchIndex;
+      }
+
+      const delta = Date.now() - stats.mtimeMs;
+      const minute = 60 * 1000;
+      const delay = 45 * minute;
+
+      const whenst = duration => `~${Math.ceil(duration / minute)} min`;
+
+      if (delta < delay) {
+        logInfo`Search index was generated recently, skipping for this build.`;
+        logInfo`Next scheduled is in ${whenst(delay - delta)}, or by using ${'--refresh-search'}.`;
+        Object.assign(stepStatusSummary.buildSearchIndex, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `earlier than scheduled based on file mtime`,
+        });
+      } else {
+        logInfo`Search index hasn't been generated for a little while.`;
+        logInfo`It'll be generated this build, then again in ${whenst(delay)}.`;
+        Object.assign(stepStatusSummary.buildSearchIndex, {
+          status: STATUS_NOT_STARTED,
+          annotation: `past when shceduled based on file mtime`,
+        });
+      }
+
+      paragraph = false;
+    }
+
     fallbackStep('verifyImagePaths', {
       default: 'perform',
       buildConfig: 'mediaValidation',
@@ -730,33 +905,43 @@ async function main() {
     });
   }
 
+  // TODO: These should error if the option was actually provided but
+  // the relevant steps were already disabled for some other reason.
   switch (precacheMode) {
     case 'common':
-      Object.assign(stepStatusSummary.precacheAllData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is common, not all`,
-      });
+      if (stepStatusSummary.precacheAllData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheAllData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is common, not all`,
+        });
+      }
 
       break;
 
     case 'all':
-      Object.assign(stepStatusSummary.precacheCommonData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is all, not common`,
-      });
+      if (stepStatusSummary.precacheCommonData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheCommonData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is all, not common`,
+        });
+      }
 
       break;
 
     case 'none':
-      Object.assign(stepStatusSummary.precacheCommonData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is none`,
-      });
+      if (stepStatusSummary.precacheCommonData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheCommonData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is none`,
+        });
+      }
 
-      Object.assign(stepStatusSummary.precacheAllData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is none`,
-      });
+      if (stepStatusSummary.precacheAllData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheAllData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is none`,
+        });
+      }
 
       break;
   }
@@ -778,6 +963,44 @@ async function main() {
     return false;
   }
 
+  // If we're going to require a build mode and none is specified,
+  // exit and show what to do. This must not precede anything that might
+  // disable the build (e.g. changing its status to STATUS_NOT_APPLICABLE).
+
+  if (stepStatusSummary.performBuild.status === STATUS_NOT_STARTED) {
+    if (selectedBuildMode) {
+      logInfo`Will use specified build mode: ${selectedBuildModeFlag}`;
+    } else {
+      showHelpForOptions({
+        heading: `Please specify a build mode:`,
+        options: buildModeFlagOptions,
+      });
+
+      console.log(
+        `(Use ${colors.bright('--help')} for general info and all options, or specify\n` +
+        ` a build mode alongside ${colors.bright('--help')} for that mode's options!`);
+
+      for (const step of Object.values(stepStatusSummary)) {
+        Object.assign(step, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `no build mode provided`,
+        });
+      }
+
+      return false;
+    }
+  } else if (selectedBuildMode) {
+    if (stepStatusSummary.performBuild.annotation) {
+      logError`You've specified a build mode, ${selectedBuildModeFlag}, but it won't be used,`;
+      logError`according to the message: ${`"${stepStatusSummary.performBuild.annotation}"`}`;
+    } else {
+      logError`You've specified a build mode, ${selectedBuildModeFlag}, but it won't be used,`;
+      logError`probably because of another option you've provided.`;
+    }
+    logError`Please remove ${'--' + selectedBuildModeFlag} or the conflicting option.`;
+    return false;
+  }
+
   Object.assign(stepStatusSummary.determineMediaCachePath, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
@@ -885,17 +1108,15 @@ async function main() {
         logError`a new path with ${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}.`;
         break;
 
-      case `missing wiki cache to create media cache inside`:
+      case `media path not provided`: /* unreachable */
         console.error('');
-        logError`It looks like you're starting totally fresh, so please`;
-        logError`create a ${'cache'} folder and provide it with ${'--cache-path'}`;
-        logError`or ${'HSMUSIC_CACHE'}. The media cache will automatically be`;
-        logError`generated inside of this folder!`;
+        logError`It seems a ${'--media-path'} (or ${'HSMUSIC_MEDIA'}) wasn't provided.`;
+        logError`Make sure one of these is actually pointing to a path that exists.`;
         break;
 
-      case `media path not provided`: /* unreachable */
+      case `cache path not provided`: /* unreachable */
         console.error('');
-        logError`It seems a ${'--media-path'} (or ${'HSMUSIC_MEDIA'}) wasn't provided.`;
+        logError`It seems a ${'--cache-path'} (or ${'HSMUSIC_CACHE'}) wasn't provided.`;
         logError`Make sure one of these is actually pointing to a path that exists.`;
         break;
     }
@@ -1066,8 +1287,6 @@ async function main() {
     CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
   }
 
-  let paragraph = false;
-
   Object.assign(stepStatusSummary.loadDataFiles, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
@@ -1340,6 +1559,8 @@ async function main() {
     });
   }
 
+  const urls = generateURLs(urlSpec);
+
   // Filter out any things with duplicate directories throughout the data,
   // warning about them too.
 
@@ -1847,8 +2068,6 @@ async function main() {
     timeEnd: Date.now(),
   });
 
-  const urls = generateURLs(urlSpec);
-
   let missingImagePaths;
 
   if (stepStatusSummary.verifyImagePaths.status === STATUS_NOT_APPLICABLE) {
@@ -1998,7 +2217,49 @@ async function main() {
     }
   }
 
-  let webRoutes = null;
+  if (stepStatusSummary.buildSearchIndex.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.buildSearchIndex, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    try {
+      await writeSearchData({
+        thumbsCache,
+        urls,
+        wikiCachePath,
+        wikiData,
+      });
+
+      logInfo`Search data successfully written - nice!`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.buildSearchIndex, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`There was an error preparing or writing search data.`;
+      fileIssue();
+      logWarn`Any existing search data will be reused, and search may be`;
+      logWarn`generally dysfunctional. The site should work otherwise, though!`;
+
+      console.log('');
+      paragraph = true;
+
+      Object.assign(stepStatusSummary.buildSearchIndex, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+      });
+    }
+  }
+
+  let webRouteSources = null;
+  let preparedWebRoutes = null;
 
   if (stepStatusSummary.identifyWebRoutes.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.identifyWebRoutes, {
@@ -2009,7 +2270,7 @@ async function main() {
     const fromRoot = urls.from('shared.root');
 
     try {
-      const webRouteSources = await identifyAllWebRoutes({
+      webRouteSources = await identifyAllWebRoutes({
         mediaCachePath,
         mediaPath,
         wikiCachePath,
@@ -2025,7 +2286,7 @@ async function main() {
           {message: `Errors computing effective web route paths`},);
 
       aggregate.close();
-      webRoutes = result;
+      preparedWebRoutes = result;
     } catch (error) {
       if (!paragraph) console.log('');
       niceShowAggregate(error);
@@ -2045,7 +2306,7 @@ async function main() {
       return false;
     }
 
-    logInfo`Successfully determined web routes.`;
+    logInfo`Successfully determined web routes - nice!`;
     paragraph = false;
 
     Object.assign(stepStatusSummary.identifyWebRoutes, {
@@ -2054,6 +2315,12 @@ async function main() {
     });
   }
 
+  wikiData.wikiInfo.searchDataAvailable =
+    (webRouteSources
+      ? webRouteSources
+          .some(({to}) => to[0].startsWith('searchData'))
+      : null);
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     return true;
   }
@@ -2104,6 +2371,7 @@ async function main() {
       dataPath,
       mediaPath,
       mediaCachePath,
+      wikiCachePath,
       queueSize,
       srcRootPath: __dirname,
 
@@ -2113,10 +2381,9 @@ async function main() {
       thumbsCache,
       urls,
       urlSpec,
-      webRoutes,
+      webRoutes: preparedWebRoutes,
       wikiData,
 
-      cachebust: '?' + CACHEBUST,
       closeLanguageWatchers,
       developersComment,
       getSizeOfAdditionalFile,