« get me outta code hell

Merge pull request #143 from hsmusic/better-cli-args - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-01-30 20:33:41 -0400
committerGitHub <noreply@github.com>2023-01-30 20:33:41 -0400
commit5e5ae8defa884984eb8a6d11ac9917bf81fd03d6 (patch)
tree5dc88136665ad865f7802ca861dacca6fa819362 /src
parent082a02d99de282e198c180164b752539bee1571d (diff)
parent5631be8ac1d77aab9a63dd4e5e72f668dddf0de6 (diff)
Merge pull request #143 from hsmusic/better-cli-args
Improve CLI arguments
Diffstat (limited to 'src')
-rw-r--r--src/page/album-commentary.js2
-rw-r--r--src/page/album.js2
-rw-r--r--src/page/artist-alias.js2
-rw-r--r--src/page/artist.js2
-rw-r--r--src/page/flash.js2
-rw-r--r--src/page/group.js2
-rw-r--r--src/page/homepage.js2
-rw-r--r--src/page/listing.js2
-rw-r--r--src/page/news.js2
-rw-r--r--src/page/static.js2
-rw-r--r--src/page/tag.js2
-rw-r--r--src/page/track.js2
-rwxr-xr-xsrc/upd8.js139
-rw-r--r--src/util/cli.js69
-rw-r--r--src/write/build-modes/live-dev-server.js11
-rw-r--r--src/write/build-modes/static-build.js14
16 files changed, 219 insertions, 38 deletions
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
index 74eee2b0..5ac6cd26 100644
--- a/src/page/album-commentary.js
+++ b/src/page/album-commentary.js
@@ -4,6 +4,8 @@ import {generateAlbumExtrasPageNav} from './album.js';
 import {accumulateSum} from '../util/sugar.js';
 import {filterAlbumsByCommentary} from '../util/wiki-data.js';
 
+export const description = `per-album artist commentary pages & index`
+
 export function condition({wikiData}) {
   return filterAlbumsByCommentary(wikiData.albumData).length;
 }
diff --git a/src/page/album.js b/src/page/album.js
index 906c02ea..90f6afa5 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -12,6 +12,8 @@ import {
   getTotalDuration,
 } from '../util/wiki-data.js';
 
+export const description = `per-album info & track artwork gallery pages`;
+
 export function targets({wikiData}) {
   return wikiData.albumData;
 }
diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js
index e2b16046..f867d123 100644
--- a/src/page/artist-alias.js
+++ b/src/page/artist-alias.js
@@ -1,6 +1,8 @@
 // Artist alias redirect pages.
 // (Makes old permalinks bring visitors to the up-to-date page.)
 
+export const description = `redirects for aliased artist names`;
+
 export function targets({wikiData}) {
   return wikiData.artistAliasData;
 }
diff --git a/src/page/artist.js b/src/page/artist.js
index 235fe113..af0c7c43 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -15,6 +15,8 @@ import {
   sortChronologically,
 } from '../util/wiki-data.js';
 
+export const description = `per-artist info & artwork gallery pages`;
+
 export function targets({wikiData}) {
   return wikiData.artistData;
 }
diff --git a/src/page/flash.js b/src/page/flash.js
index 581092a6..8f2506fc 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -3,6 +3,8 @@
 import {empty} from '../util/sugar.js';
 import {getFlashLink} from '../util/wiki-data.js';
 
+export const description = `flash & game pages`;
+
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableFlashesAndGames;
 }
diff --git a/src/page/group.js b/src/page/group.js
index 6bfd1532..54a8358e 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -9,6 +9,8 @@ import {
   sortChronologically,
 } from '../util/wiki-data.js';
 
+export const description = `per-group info & album gallery pages`;
+
 export function targets({wikiData}) {
   return wikiData.groupData;
 }
diff --git a/src/page/homepage.js b/src/page/homepage.js
index cb1e1da1..9722f105 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -11,6 +11,8 @@ import {
   getNewReleases,
 } from '../util/wiki-data.js';
 
+export const description = `main wiki homepage`;
+
 export function writeTargetless({wikiData}) {
   const {newsData, homepageLayout, wikiInfo} = wikiData;
 
diff --git a/src/page/listing.js b/src/page/listing.js
index dce38526..37f4d155 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -12,6 +12,8 @@ import {empty} from '../util/sugar.js';
 
 import {getTotalDuration} from '../util/wiki-data.js';
 
+export const description = `wiki-wide listing pages & index`;
+
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableListings;
 }
diff --git a/src/page/news.js b/src/page/news.js
index 61a52dc0..e042e8ae 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -1,5 +1,7 @@
 // News entry & index page specifications.
 
+export const description = `per-entry news pages & index`;
+
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableNews;
 }
diff --git a/src/page/static.js b/src/page/static.js
index 1689d16b..2da71b74 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -2,6 +2,8 @@
 // wiki data folder, used for a variety of purposes, e.g. wiki info,
 // changelog, and so on.)
 
+export const description = `static wiki-wide content pages specified in data`;
+
 export function targets({wikiData}) {
   return wikiData.staticPageData;
 }
diff --git a/src/page/tag.js b/src/page/tag.js
index da4f194a..ee62038e 100644
--- a/src/page/tag.js
+++ b/src/page/tag.js
@@ -1,5 +1,7 @@
 // Art tag page specification.
 
+export const description = `per-artwork-tag gallery pages`;
+
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableArtTagUI;
 }
diff --git a/src/page/track.js b/src/page/track.js
index 94c9c40a..495bf42d 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -18,6 +18,8 @@ import {
   sortChronologically,
 } from '../util/wiki-data.js';
 
+export const description = `per-track info pages`;
+
 export function targets({wikiData}) {
   return wikiData.trackData;
 }
diff --git a/src/upd8.js b/src/upd8.js
index 21413a17..3bcdf884 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -34,6 +34,7 @@
 import {execSync} from 'child_process';
 import * as path from 'path';
 import {fileURLToPath} from 'url';
+import wrap from 'word-wrap';
 
 import genThumbs from './gen-thumbs.js';
 import {listingSpec, listingTargetSpec} from './listing-spec.js';
@@ -57,9 +58,10 @@ 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 {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';
@@ -99,18 +101,27 @@ if (!validateReplacerSpec(replacerSpec, {find, link})) {
 async function main() {
   Error.stackTraceLimit = Infinity;
 
+  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]: () => {},
-
-      ...Object.fromEntries(Object.keys(buildModes)
-        .map((key) => [key, {type: 'flag'}])),
+      ...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.`;
@@ -118,6 +129,7 @@ async function main() {
     return;
   } else {
     selectedBuildModeFlag = selectedBuildModeFlags[0];
+    usingDefaultBuildMode = false;
     logInfo`Using specified build mode: ${selectedBuildModeFlag}`;
   }
 
@@ -129,13 +141,19 @@ async function main() {
     listingTargetSpec,
   };
 
-  const cliOptions = await parseOptions(process.argv.slice(2), {
-    ...selectedBuildMode.getCLIOptions(),
+  const buildOptions = selectedBuildMode.getCLIOptions();
+
+  const commonOptions = {
+    'help': {
+      help: `Display usage info and basic information for the \`hsmusic\` command`,
+      type: 'flag',
+    },
 
     // 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',
     },
 
@@ -143,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',
     },
 
@@ -157,6 +176,7 @@ async function main() {
     // 8uild with the default (English) strings if this path is left
     // unspecified.
     'lang-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',
     },
 
@@ -164,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',
     },
 
@@ -177,6 +199,7 @@ async function main() {
     // generating site HTML yet? This flag will cut execution off right
     // 8efore any site 8uilding actually happens.
     'no-build': {
+      help: `Don't run a build of the site at all; only process data/media and report any errors detected`,
       type: 'flag',
     },
 
@@ -185,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';
@@ -202,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',
     },
 
@@ -215,12 +241,109 @@ 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,
   });
 
+  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(``);
+
+    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!
@@ -237,7 +360,7 @@ async function main() {
   // 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'] ?? 500);
+  const queueSize = +(cliOptions['queue-size'] ?? defaultQueueSize);
 
   {
     let errored = false;
diff --git a/src/util/cli.js b/src/util/cli.js
index f1a31900..1ddc90e0 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -64,8 +64,10 @@ export async function parseOptions(options, optionDescriptorMap) {
   // options is the array of options you want to process;
   // optionDescriptorMap is a mapping of option names to objects that describe
   // the expected value for their corresponding options.
-  // Returned is a mapping of any specified option names to their values, or
-  // a process.exit(1) and error message if there were any issues.
+  //
+  // Returned is...
+  // - a mapping of any specified option names to their values
+  // - a process.exit(1) and error message if there were any issues
   //
   // Here are examples of optionDescriptorMap to cover all the things you can
   // do with it:
@@ -95,11 +97,10 @@ export async function parseOptions(options, optionDescriptorMap) {
   // ['--directory', 'apple'] -> {'directory': 'apple'}
   // ['--directory', 'artichoke'] -> (error)
   // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']}
-  //
-  // TODO: Be able to validate the values in a series option.
 
   const handleDashless = optionDescriptorMap[parseOptions.handleDashless];
   const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown];
+
   const result = Object.create(null);
   for (let i = 0; i < options.length; i++) {
     const option = options[i];
@@ -107,6 +108,7 @@ export async function parseOptions(options, optionDescriptorMap) {
       // --x can be a flag or expect a value or series of values
       let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x']
       let descriptor = optionDescriptorMap[name];
+
       if (!descriptor) {
         if (handleUnknown) {
           handleUnknown(option);
@@ -116,36 +118,49 @@ export async function parseOptions(options, optionDescriptorMap) {
         }
         continue;
       }
+
       if (descriptor.alias) {
         name = descriptor.alias;
         descriptor = optionDescriptorMap[name];
       }
-      if (descriptor.type === 'flag') {
-        result[name] = true;
-      } else if (descriptor.type === 'value') {
-        let value = option.slice(2).split('=')[1];
-        if (!value) {
-          value = options[++i];
-          if (!value || value.startsWith('-')) {
-            value = null;
-          }
+
+      switch (descriptor.type) {
+        case 'flag': {
+          result[name] = true;
+          break;
         }
-        if (!value) {
-          console.error(`Expected a value for --${name}`);
-          process.exit(1);
+
+        case 'value': {
+          let value = option.slice(2).split('=')[1];
+          if (!value) {
+            value = options[++i];
+            if (!value || value.startsWith('-')) {
+              value = null;
+            }
+          }
+
+          if (!value) {
+            console.error(`Expected a value for --${name}`);
+            process.exit(1);
+          }
+
+          result[name] = value;
+          break;
         }
-        result[name] = value;
-      } else if (descriptor.type === 'series') {
-        if (!options.slice(i).includes(';')) {
-          console.error(
-            `Expected a series of values concluding with ; (\\;) for --${name}`
-          );
-          process.exit(1);
+
+        case 'series': {
+          if (!options.slice(i).includes(';')) {
+            console.error(`Expected a series of values concluding with ; (\\;) for --${name}`);
+            process.exit(1);
+          }
+
+          const endIndex = i + options.slice(i).indexOf(';');
+          result[name] = options.slice(i + 1, endIndex);
+          i = endIndex;
+          break;
         }
-        const endIndex = i + options.slice(i).indexOf(';');
-        result[name] = options.slice(i + 1, endIndex);
-        i = endIndex;
       }
+
       if (descriptor.validate) {
         const validation = await descriptor.validate(result[name]);
         if (validation !== true) {
@@ -167,10 +182,12 @@ export async function parseOptions(options, optionDescriptorMap) {
         }
         continue;
       }
+
       if (descriptor.alias) {
         name = descriptor.alias;
         descriptor = optionDescriptorMap[name];
       }
+
       if (descriptor.type === 'flag') {
         result[name] = true;
       } else {
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 81ae5b8e..39229a9a 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -25,13 +25,20 @@ import {
   generateRedirectHTML,
 } from '../page-template.js';
 
+const defaultHost = '0.0.0.0';
+const defaultPort = 8002;
+
+export const description = `Hosts a local HTTP server which generates page content as it is requested, instead of all at once; reacts to changes in data files, so new reloads will be up-to-date with on-disk YAML data (<- not implemented yet, check back soon!)\n\nIntended for local development ONLY; this custom HTTP server is NOT rigorously tested and almost certainly has security flaws`;
+
 export function getCLIOptions() {
   return {
     host: {
+      help: `Hostname to which HTTP server is bound\nDefaults to ${defaultHost}`,
       type: 'value',
     },
 
     port: {
+      help: `Port to which HTTP server is bound\nDefaults to ${defaultPort}`,
       type: 'value',
       validate(size) {
         if (parseInt(size) !== parseFloat(size)) return 'an integer';
@@ -57,8 +64,8 @@ export async function go({
   developersComment,
   getSizeOfAdditionalFile,
 }) {
-  const host = cliOptions['host'] ?? '0.0.0.0';
-  const port = parseInt(cliOptions['port'] ?? 8002);
+  const host = cliOptions['host'] ?? defaultHost;
+  const port = parseInt(cliOptions['port'] ?? defaultPort);
 
   let targetSpecPairs = getPageSpecsWithTargets({wikiData});
   const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`,
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index 220e51f4..fa724536 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -15,7 +15,7 @@ import {serializeThings} from '../../data/serialize.js';
 import * as pageSpecs from '../../page/index.js';
 
 import link from '../../util/link.js';
-import {empty, queue} from '../../util/sugar.js';
+import {empty, queue, withEntries} from '../../util/sugar.js';
 
 import {
   logError,
@@ -34,6 +34,8 @@ import {
 
 const pageFlags = Object.keys(pageSpecs);
 
+export const description = `Generates all page content in one build (according to the contents of data files at build time) and writes them to disk, preparing the output folder for upload and serving by any static web host\n\nIntended for any production or public-facing release of a wiki; serviceable for local development, but can be a bit unwieldy and time/CPU-expensive`;
+
 export function getCLIOptions() {
   return {
     // This is the output directory. It's the one you'll upload online with
@@ -43,6 +45,7 @@ export function getCLIOptions() {
     // couple symlinked directories, so if you're uploading, you're pro8a8ly
     // gonna want to resolve those yourself.
     'out-path': {
+      help: `Specify path to output directory, into which HTML page files and other output are written and other directories are linked\n\nAlways required alongside --static-build mode, but may be provided via the HSMUSIC_OUT environment variable instead`,
       type: 'value',
     },
 
@@ -51,12 +54,14 @@ export function getCLIOptions() {
     // 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: `Apply "index.html" to the end of page links, instead of just linking to the directory (ex. "/track/ng2yu/"); useful when no local server hosting option is available and browsing build output directly off the disk drive\n\nDefinitely not intended for production: this option isn't extensively tested and may include conspicuous oddities`,
       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': {
+      help: `Skip rest and build only pages for this locale language (specify a language code)`,
       type: 'value',
     },
 
@@ -65,7 +70,12 @@ export function getCLIOptions() {
     // 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'}])),
+    ...withEntries(pageSpecs, entries => entries.map(
+      ([key, spec]) => [key, {
+        help: spec.description &&
+          `Skip rest and build only:\n${spec.description}`,
+        type: 'flag',
+      }])),
   };
 }